跳到主要内容

会议室预订系统:用户管理模块-管理端用户列表页面

前面我们写了用户端的一些页面,这节继续来写管理端的。

涉及到这些页面:

这节我们来写前两个。

先新建个 react 项目:

npx create-react-app --template=typescript meeting_room_booking_system_frontend_admin

进入项目目录,把开发服务跑起来:

npm run start

浏览器访问 http://localhost:3000 可以看到这个界面:

就说明 react 项目成功跑起来了。

然后我们添加 router:

npm install --save react-router-dom

在 index.tsx 加上路由的配置:

import ReactDOM from "react-dom/client";
import "./index.css";
import {
RouterProvider,
createBrowserRouter,
Link,
Outlet,
} from "react-router-dom";

function Index() {
return (
<div>
index<Outlet></Outlet>
</div>
);
}
function ErrorPage() {
return <div>Error Page</div>;
}

function UserManage() {
return <div>user manage</div>;
}

function Login() {
return <div>login</div>;
}

const routes = [
{
path: "/",
element: <Index></Index>,
errorElement: <ErrorPage />,
children: [
{
path: "user_manage",
element: <UserManage />,
},
],
},
{
path: "login",
element: <Login />,
},
];
const router = createBrowserRouter(routes);

const root = ReactDOM.createRoot(
document.getElementById("root") as HTMLElement
);

root.render(<RouterProvider router={router} />);

配置了 4 个路由:

访问 /login 的时候,渲染 Login 组件。

访问 / 的时候,渲染 Index 组件。

访问 /user_manage 的时候,渲染 / 和 user_manage 的二级路由,也就是 Index + UserManage 组件。

以及出错的时候,渲染 ErrorPage 组件。

测试下:

都没问题。

把 src 目录下其余文件去掉:

然后创建 4 个组件:

src/pages/Login/Login.tsx

export function Login() {
return <div>login</div>;
}

src/pages/Index/Index.tsx

import { Outlet } from "react-router-dom";

export function Index() {
return (
<div>
Index<Outlet></Outlet>
</div>
);
}

src/pages/UserManage/UserManage.tsx

export function UserManage() {
return <div>UserManage</div>;
}

src/pages/ErrorPage/ErrorPage.tsx

export function ErrorPage() {
return <div>Error Page</div>;
}

改下 index.tsx 配置对应的路由:

import ReactDOM from "react-dom/client";
import "./index.css";
import {
RouterProvider,
createBrowserRouter,
Link,
Outlet,
} from "react-router-dom";
import { Index } from "./pages/Index/Index";
import { ErrorPage } from "./pages/ErrorPage/ErrorPage";
import { UserManage } from "./pages/UserManage/UserManage";
import { Login } from "./pages/Login/Login";

const routes = [
{
path: "/",
element: <Index></Index>,
errorElement: <ErrorPage />,
children: [
{
path: "user_manage",
element: <UserManage />,
},
],
},
{
path: "login",
element: <Login />,
},
];
const router = createBrowserRouter(routes);

const root = ReactDOM.createRoot(
document.getElementById("root") as HTMLElement
);

root.render(<RouterProvider router={router} />);

测试下:

都没啥问题。

然后来写 Login 页面:

引入 Ant Design 组件库:

npm install antd --save

在 Login 组件引入 DatePicker 组件:

import { DatePicker } from "antd";

export function Login() {
return (
<div>
<DatePicker />
</div>
);
}

没啥问题,说明 antd 引入成功了。

然后我们把登录页面写一下:

import { Button, Checkbox, Form, Input } from "antd";
import "./login.css";
import { useCallback } from "react";

interface LoginUser {
username: string;
password: string;
}

const layout1 = {
labelCol: { span: 4 },
wrapperCol: { span: 20 },
};

export function Login() {
const onFinish = useCallback((values: LoginUser) => {
console.log(values);
}, []);

return (
<div id="login-container">
<h1>会议室预订系统</h1>
<Form
{...layout1}
onFinish={onFinish}
colon={false}
autoComplete="off">
<Form.Item
label="用户名"
name="username"
rules={[{ required: true, message: "请输入用户名!" }]}>
<Input />
</Form.Item>

<Form.Item
label="密码"
name="password"
rules={[{ required: true, message: "请输入密码!" }]}>
<Input.Password />
</Form.Item>

<Form.Item label=" ">
<Button className="btn" type="primary" htmlType="submit">
登录
</Button>
</Form.Item>
</Form>
</div>
);
}

这里和用户端差不多.

login.css 如下:

#login-container {
width: 400px;
margin: 100px auto 0 auto;
text-align: center;
}
#login-container .links {
display: flex;
justify-content: space-between;
}
#login-container .btn {
width: 100%;
}

访问 /login,可以看到现在的登录页面:

然后看一下接口文档 http://localhost:3005/api-doc

传入用户名、密码、返回用户信息和 token。

在 postman 里测试下登录接口:

然后在点击登录按钮之后,用 axios 调用它:

安装 axios:

npm install axios

在前端项目创建个 src/interfaces/interfaces.ts

import axios from "axios";

const axiosInstance = axios.create({
baseURL: "http://localhost:3005/",
timeout: 3000,
});

export async function login(username: string, password: string) {
return await axiosInstance.post("/user/admin/login", {
username,
password,
});
}

在这里集中管理接口。

然后 onFinish 里调用:

const navigate = useNavigate();

const onFinish = useCallback(async (values: LoginUser) => {
const res = await login(values.username, values.password);

const { code, message: msg, data } = res.data;
if (res.status === 201 || res.status === 200) {
message.success("登录成功");

localStorage.setItem("access_token", data.accessToken);
localStorage.setItem("refresh_token", data.refreshToken);
localStorage.setItem("user_info", JSON.stringify(data.userInfo));

setTimeout(() => {
navigate("/");
}, 1000);
} else {
message.error(data || "系统繁忙,请稍后再试");
}
}, []);

这里和用户端一摸一样。

登录下:

提示 400 错误没处理。

因为接口返回 400 的时候,axios 会抛异常:

我们加一个响应的 interceptor,返回 error.response 而不是 Promise.reject(error.response)

axiosInstance.interceptors.response.use(
(response) => {
return response;
},
async (error) => {
return error.response;
}
);

再测试下:

当用户不存在时:

当密码错误时:

登录成功时:

都没啥问题。

这样,管理员登录的前后端功能就都完成了。

然后是用户管理的页面:

修改下 Index.tsx

import { UserOutlined } from "@ant-design/icons";
import { Outlet } from "react-router-dom";
import "./index.css";

export function Index() {
return (
<div id="index-container">
<div className="header">
<h1>会议室预定系统-后台管理</h1>
<UserOutlined className="icon" />
</div>
<div className="body">
<Outlet></Outlet>
</div>
</div>
);
}

这里用到了 antd 的 icon 组件,需要安装用到的包:

npm install @ant-design/icons --save

css 如下:

#index-container {
height: 100vh;
display: flex;
flex-direction: column;
}
#index-container .header {
height: 80px;
border-bottom: 1px solid #aaa;
line-height: 80px;
display: flex;
justify-content: space-between;
padding: 0 20px;
}
#index-container h1 {
margin: 0;
}

#index-container .icon {
font-size: 40px;
margin-top: 20px;
}
#index-container .body {
flex: 1;
}

测试下:

没啥问题。

不知道同学们有没有发现,其实这个页面应该是三级路由:

因为左边这部分也是要多个页面共用的。

我们改一下路由配置:

const routes = [
{
path: "/",
element: <Index></Index>,
errorElement: <ErrorPage />,
children: [
{
path: "/",
element: <Menu></Menu>,
children: [
{
path: "user_manage",
element: <UserManage />,
},
],
},
],
},
{
path: "login",
element: <Login />,
},
];

添加 src/pages/Menu/Menu.tsx

import { Outlet } from "react-router-dom";

export function Menu() {
return (
<div>
menu <Outlet></Outlet>
</div>
);
}

渲染出来是这样的:

我们来写一下 Menu 组件:

import { Outlet } from "react-router-dom";
import { Menu as AntdMenu, MenuProps } from "antd";
import "./menu.css";

const items: MenuProps["items"] = [
{
key: "1",
label: "会议室管理",
},
{
key: "2",
label: "预定管理",
},
{
key: "3",
label: "用户管理",
},
{
key: "4",
label: "统计",
},
];

export function Menu() {
return (
<div id="menu-container">
<div className="menu-area">
<AntdMenu defaultSelectedKeys={["3"]} items={items} />
</div>
<div className="content-area">
<Outlet></Outlet>
</div>
</div>
);
}

menu.css 如下:

#menu-container {
display: flex;
flex-direction: row;
}
#menu-container .menu-area {
width: 200px;
}

渲染出来是这样的:

然后来写 UserManage 组件:

可以分为 2 部分,上面的搜索表单、下面的结果表格。

我们来写一下:

import { Button, Form, Input, Table } from "antd";
import { useCallback } from "react";
import "./UserManage.css";

interface SearchUser {
username: string;
nickName: string;
email: string;
}

export function UserManage() {
const searchUser = useCallback(async (values: SearchUser) => {
console.log(values);
}, []);

return (
<div id="userManage-container">
<div className="userManage-form">
<Form
onFinish={searchUser}
name="search"
layout="inline"
colon={false}>
<Form.Item label="用户名" name="username">
<Input />
</Form.Item>

<Form.Item label="昵称" name="nickName">
<Input />
</Form.Item>

<Form.Item
label="邮箱"
name="email"
rules={[
{ type: "email", message: "请输入合法邮箱地址!" },
]}>
<Input />
</Form.Item>

<Form.Item label=" ">
<Button type="primary" htmlType="submit">
搜索用户
</Button>
</Form.Item>
</Form>
</div>
<div className="userManage-table"></div>
</div>
);
}

UserManage.css

#userManage-container {
padding: 20px;
}

先把 form 部分写完。

测试下:

然后再写 table 部分:

import { Button, Form, Input, Table } from "antd";
import { useCallback } from "react";
import "./UserManage.css";
import { ColumnsType } from "antd/es/table";

interface SearchUser {
username: string;
nickName: string;
email: string;
}

interface UserSearchResult {
username: string;
nickName: string;
email: string;
headPic: string;
createTime: Date;
}
const columns: ColumnsType<UserSearchResult> = [
{
title: "用户名",
dataIndex: "username",
},
{
title: "头像",
dataIndex: "headPic",
},
{
title: "昵称",
dataIndex: "nickName",
},
{
title: "邮箱",
dataIndex: "email",
},
{
title: "注册时间",
dataIndex: "createTime",
},
];

const data = [
{
key: "1",
username: "xx",
headPic: "xxx.png",
nickName: "xxx",
createTime: new Date(),
},
{
key: "12",
username: "yy",
headPic: "yy.png",
nickName: "yyy",
createTime: new Date(),
},
];

export function UserManage() {
const searchUser = useCallback(async (values: SearchUser) => {
console.log(values);
}, []);

return (
<div id="userManage-container">
<div className="userManage-form">
<Form
onFinish={searchUser}
name="search"
layout="inline"
colon={false}>
<Form.Item label="用户名" name="username">
<Input />
</Form.Item>

<Form.Item label="昵称" name="nickName">
<Input />
</Form.Item>

<Form.Item
label="邮箱"
name="email"
rules={[
{ type: "email", message: "请输入合法邮箱地址!" },
]}>
<Input />
</Form.Item>

<Form.Item label=" ">
<Button type="primary" htmlType="submit">
搜索用户
</Button>
</Form.Item>
</Form>
</div>
<div className="userManage-table">
<Table
columns={columns}
dataSource={data}
pagination={{
pageSize: 10,
}}
/>
</div>
</div>
);
}

渲染出来是这样的:

然后我们调用下搜索接口。

看下接口文档:

在 postman 里调用下:

这个接口是需要登录的。

我们先登录一下:

带上 access_token 再访问:

返回了 8 条数据。

然后我们在页面里调用下:

先把之前写的 axios 的 interceptors 自动添加 authorization 的 header,自动 refresh token 的逻辑拿过来:

axiosInstance.interceptors.request.use(function (config) {
const accessToken = localStorage.getItem("access_token");

if (accessToken) {
config.headers.authorization = "Bearer " + accessToken;
}
return config;
});

axiosInstance.interceptors.response.use(
(response) => {
return response;
},
async (error) => {
let { data, config } = error.response;

if (data.code === 401 && !config.url.includes("/user/admin/refresh")) {
const res = await refreshToken();

if (res.status === 200 || res.status === 201) {
return axiosInstance(config);
} else {
message.error(res.data);

setTimeout(() => {
window.location.href = "/login";
}, 1500);
}
} else {
return error.response;
}
}
);

async function refreshToken() {
const res = await axiosInstance.get("/user/admin/refresh", {
params: {
refresh_token: localStorage.getItem("refresh_token"),
},
});
localStorage.setItem("access_token", res.data.access_token);
localStorage.setItem("refresh_token", res.data.refresh_token);
return res;
}

然后添加一个接口:

export async function userSearch(
username: string,
nickName: string,
email: string,
pageNo: number,
pageSize: number
) {
return await axiosInstance.get("/user/list", {
params: {
username,
nickName,
email,
pageNo,
pageSize,
},
});
}

在页面调用下:

const [pageNo, setPageNo] = useState<number>(1);
const [pageSize, setPageSize] = useState<number>(10);
const [userResult, setUserResult] = useState<UserSearchResult[]>();

const searchUser = useCallback(async (values: SearchUser) => {
const res = await userSearch(
values.username,
values.nickName,
values.email,
pageNo,
pageSize
);

const { data } = res.data;
if (res.status === 201 || res.status === 200) {
setUserResult(
data.users.map((item: UserSearchResult) => {
return {
key: item.username,
...item,
};
})
);
} else {
message.error(data || "系统繁忙,请稍后再试");
}
}, []);

使用 useState 创建 pageNo、pageSize、userResult 这三个状态。

请求接口,成功后把数据设置到 userResult。

然后修改下 table 的 dataSource:

测试下:

先登录。

然后访问 http://localhost:3000/user_manage

搜索接口对接成功。

然后再对接下分页:

<Table
columns={columns}
dataSource={userResult}
pagination={{
current: pageNo,
pageSize: pageSize,
onChange: changePage,
}}
/>

设置 pageNo 和 pageSize,并监听 onChange 事件

useEffect(() => {
searchUser({
username: "",
email: "",
nickName: "",
});
}, [pageNo, pageSize]);

const changePage = useCallback(function (pageNo: number, pageSize: number) {
setPageNo(pageNo);
setPageSize(pageSize);
}, []);

分页设置改变的时候,设置 pageNo 和 pageSize。

并且 useEffect 监听这两个 state,在变化的时候,重新发送请求。

这样,刚进入页面的时候,就会触发一次渲染,并且在分页设置改变时也会触发:

然后修改下 headPic,改为图片:

const columns: ColumnsType<UserSearchResult> = [
{
title: "用户名",
dataIndex: "username",
},
{
title: "头像",
dataIndex: "headPic",
render: (value) => {
return value ? (
<Image width={50} src={`http://localhost:3005/${value}`} />
) : (
""
);
},
},
{
title: "昵称",
dataIndex: "nickName",
},
{
title: "邮箱",
dataIndex: "email",
},
{
title: "注册时间",
dataIndex: "createTime",
},
];

这里用的是 antd 的 Image 组件,有预览的功能:

原型图还有个冻结功能:

看下接口文档:

很简单,就是个 get 接口。

我们在表格里加一列:

{
title: '操作',
render: (_, record) => (
<a href="#" onClick={() => {freezeUser(record.id)}}>冻结</a>
)
}

这里用到了 id,我们在类型里加一下:

然后在 interfaces.tsx 添加这个接口:

export async function freeze(id: number) {
return await axiosInstance.get("/user/freeze", {
params: {
id,
},
});
}

在组件里创建 freezeUser 方法:

async function freezeUser(id: number) {
const res = await freeze(id);

const { data } = res.data;
if (res.status === 201 || res.status === 200) {
message.success("冻结成功");
} else {
message.error(data || "系统繁忙,请稍后再试");
}
}

测试下:

2023-09-12 11.31.21.gif

然后我们还要把冻结状态显示出来:

这部分数据是返回了的:

需要添加一列:

{
title: '状态',
dataIndex: 'isFrozen',
render: (_, record) => (
record.isFrozen ? <Badge status="success">已冻结</Badge> : ''
)
},

在类型部分也要添加下:

测试下:

冻结之后,刷新页面,会显示已冻结。

这里我们在冻结之后自动刷新下。

这需要把逻辑移到组件内:

把 columns 移到组件内,用 useMemo 包裹,这样只会创建一次:

freeezeUser 也是:

const freezeUser = useCallback(async (id: number) => {
const res = await freeze(id);

const { data } = res.data;
if (res.status === 201 || res.status === 200) {
message.success("冻结成功");
} else {
message.error(data || "系统繁忙,请稍后再试");
}
}, []);

添加一个 num 的 state,冻结之后设置一个随机值:

把它添加到 useEffect 的依赖里,这样就能触发重新搜索:

测试下:

但其实现在这个重新搜索有问题:

我搜索之后再冻结,然后刷新就丢失了搜索条件了。

这里需要搜索的时候带上当前的条件:

用 useForm 拿到 form 的 api:

然后在搜索的时候拿到最新的表单值:

useEffect(() => {
searchUser({
username: form.getFieldValue("username"),
email: form.getFieldValue("email"),
nickName: form.getFieldValue("nickName"),
});
}, [pageNo, pageSize, num]);

这样就可以了:

这样,用户管理页面就写完了。

全部代码如下:

import { Badge, Button, Form, Image, Input, Table, message } from "antd";
import { useCallback, useEffect, useMemo, useState } from "react";
import "./UserManage.css";
import { ColumnsType } from "antd/es/table";
import { freeze, userSearch } from "../../interfaces/interfaces";
import { useForm } from "antd/es/form/Form";

interface SearchUser {
username: string;
nickName: string;
email: string;
}

interface UserSearchResult {
id: number;
username: string;
nickName: string;
email: string;
headPic: string;
createTime: Date;
isFrozen: boolean;
}

export function UserManage() {
const [pageNo, setPageNo] = useState<number>(1);
const [pageSize, setPageSize] = useState<number>(10);
const [userResult, setUserResult] = useState<UserSearchResult[]>();
const [num, setNum] = useState(0);

const columns: ColumnsType<UserSearchResult> = useMemo(
() => [
{
title: "用户名",
dataIndex: "username",
},
{
title: "头像",
dataIndex: "headPic",
render: (value) => {
return value ? (
<Image
width={50}
src={`http://localhost:3005/${value}`}
/>
) : (
""
);
},
},
{
title: "昵称",
dataIndex: "nickName",
},
{
title: "邮箱",
dataIndex: "email",
},
{
title: "注册时间",
dataIndex: "createTime",
},
{
title: "状态",
dataIndex: "isFrozen",
render: (_, record) =>
record.isFrozen ? (
<Badge status="success">已冻结</Badge>
) : (
""
),
},
{
title: "操作",
render: (_, record) => (
<a
href="#"
onClick={() => {
freezeUser(record.id);
}}>
冻结
</a>
),
},
],
[]
);

const freezeUser = useCallback(async (id: number) => {
const res = await freeze(id);

const { data } = res.data;
if (res.status === 201 || res.status === 200) {
message.success("冻结成功");
setNum(Math.random());
} else {
message.error(data || "系统繁忙,请稍后再试");
}
}, []);

const searchUser = useCallback(async (values: SearchUser) => {
const res = await userSearch(
values.username,
values.nickName,
values.email,
pageNo,
pageSize
);

const { data } = res.data;
if (res.status === 201 || res.status === 200) {
setUserResult(
data.users.map((item: UserSearchResult) => {
return {
key: item.username,
...item,
};
})
);
} else {
message.error(data || "系统繁忙,请稍后再试");
}
}, []);

const [form] = useForm();

useEffect(() => {
searchUser({
username: form.getFieldValue("username"),
email: form.getFieldValue("email"),
nickName: form.getFieldValue("nickName"),
});
}, [pageNo, pageSize, num]);

const changePage = useCallback(function (pageNo: number, pageSize: number) {
setPageNo(pageNo);
setPageSize(pageSize);
}, []);

return (
<div id="userManage-container">
<div className="userManage-form">
<Form
form={form}
onFinish={searchUser}
name="search"
layout="inline"
colon={false}>
<Form.Item label="用户名" name="username">
<Input />
</Form.Item>

<Form.Item label="昵称" name="nickName">
<Input />
</Form.Item>

<Form.Item
label="邮箱"
name="email"
rules={[
{ type: "email", message: "请输入合法邮箱地址!" },
]}>
<Input />
</Form.Item>

<Form.Item label=" ">
<Button type="primary" htmlType="submit">
搜索用户
</Button>
</Form.Item>
</Form>
</div>
<div className="userManage-table">
<Table
columns={columns}
dataSource={userResult}
pagination={{
current: pageNo,
pageSize: pageSize,
onChange: changePage,
}}
/>
</div>
</div>
);
}

案例代码上传了小册仓库

总结

这节我们实现了管理端的登录和用户管理页面。

和用户端的一样,都是通过 axios interceptor 自动添加 header 和自动 refresh token。

这里涉及到三级路由,第一级展示上面的 header,第二级展示左侧的 menu,第三级才是具体的页面。

使用 table 组件来渲染列表,通过 useEffect 在 pageNo、pageSize 改变的时候自动重发请求。

这样,这两个页面的前后端代码都完成了。